Skip to content

Fix #9534: Match expression does not handle remaining value: with enumerated class-string#4894

Merged
ondrejmirtes merged 2 commits into2.1.xfrom
create-pull-request/patch-jwtq7kb
Feb 12, 2026
Merged

Fix #9534: Match expression does not handle remaining value: with enumerated class-string#4894
ondrejmirtes merged 2 commits into2.1.xfrom
create-pull-request/patch-jwtq7kb

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

When using a class-string<A|B|C> parameter type with final classes and matching against all possible ::class constants in a match expression, PHPStan incorrectly reported "Match expression does not handle remaining value" even though the match was exhaustive.

The root cause was that GenericClassStringType::tryRemove() only handled the case where the generic type parameter was a single class (e.g., class-string<Foo>), but not when it was a union of classes (e.g., class-string<Foo|Bar|Baz>).

Changes

  • Modified src/Type/Generic/GenericClassStringType.php to handle union generic type parameters in tryRemove()
    • When removing a constant class-string of a final class from a class-string<A|B|C>, the corresponding ObjectType is now removed from the inner union type
    • Also added a hasClass() guard before calling getClass() for robustness
  • Added rule test in tests/PHPStan/Rules/Comparison/data/bug-9534.php covering:
    • Non-final classes (correctly still reports unhandled, since subclasses may exist)
    • Final classes with full match (no error)
    • Final classes with partial match (correctly reports remaining values)
  • Added type inference test in tests/PHPStan/Analyser/nsrt/bug-9534.php verifying type narrowing works correctly through if-chains
  • Added test method testBug9534 in tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php

Root cause

GenericClassStringType::tryRemove() checked count($genericObjectClassNames) === 1 and only handled the single-class case. For class-string<Car|Bike|Boat>, getObjectClassNames() returns 3 names, so the condition failed and the method fell through to the parent's tryRemove() which doesn't handle this case either. The type was never narrowed, so the match condition type never reached NeverType, and the match was reported as non-exhaustive.

The fix adds an elseif (count($genericObjectClassNames) > 1) branch that, for final classes, uses TypeCombinator::remove() to remove the matching ObjectType from the inner union, then wraps the result in a new GenericClassStringType.

Test

The regression test covers three scenarios:

  1. Non-final classes (class-string<Car|Bike|Boat>): Still correctly reports "unhandled" because subclasses could exist
  2. Final classes (class-string<FinalCar|FinalBike|FinalBoat>): No longer reports false positive when all cases are matched
  3. Partial final class match: Correctly reports remaining unmatched final class-string values

Fixes phpstan/phpstan#9534

ondrejmirtes and others added 2 commits February 12, 2026 14:26
- GenericClassStringType::tryRemove() only handled class-string with a single
  generic type parameter, failing for unions like class-string<A|B|C>
- Added handling for union generic types: when removing a constant class-string
  of a final class, remove the corresponding ObjectType from the inner union
- New regression tests in both rule test and nsrt for bug #9534
@ondrejmirtes ondrejmirtes merged commit 8354ecb into 2.1.x Feb 12, 2026
631 of 641 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-jwtq7kb branch February 12, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Match expression does not handle remaining value: with enumerated class-string

2 participants